Skip to content

Conversation

@kixelated
Copy link
Collaborator

No description provided.

claude and others added 4 commits February 7, 2026 00:31
Rust changes for dual v14/v15 support:
- version.rs: Add Draft15 variant and ALPN_15 constant
- parameters.rs: Add MessageParameters type for v15 message-level params
- request.rs: Add RequestOk (0x07) and RequestError (0x05) for v15
- setup.rs: v15 setup omits version fields (ALPN negotiation only)
- subscribe.rs: v15 moves fields into MessageParameters
- publish.rs: v15 moves fields into MessageParameters
- fetch.rs: v15 reorders fields and uses MessageParameters
- group.rs: v15 adds 0x30-0x3d no-priority GroupFlags range
- track.rs: v15 uses MessageParameters for TrackStatus
- session.rs: Version-aware control message dispatch
- publisher.rs: Version-aware responses (RequestOk/Error for v15)
- subscriber.rs: Version-aware responses, accept no-priority groups

All 119 tests pass, full workspace compiles.

https://claude.ai/code/session_01Tov5RP9yLYA2ALQAfqNDQf
@kixelated kixelated changed the title (AI) moq-transport-15 support (AI) Initial moq-transport-15 support Feb 7, 2026
@kixelated kixelated marked this pull request as ready for review February 11, 2026 18:44
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

Caution

Review failed

The pull request is closed.

Walkthrough

The PR adds multi-version (IETF Draft‑14 and Draft‑15) protocol support across JS and Rust codepaths. It introduces versioned encoding/decoding for setup, control, publish/subscribe, fetch, track, and object messages; new message types RequestOk and RequestError; a MessageParameters structure for message-level params; ALPN-based negotiation and expanded ALPN lists; optional publisher-priority/no-priority group headers; and numerous API and constructor adjustments to propagate negotiated version information through client, server, and session flows.

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.12% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The pull request has no description provided by the author, making it impossible to assess whether it relates to the changeset. Add a description explaining the changes, such as: 'Adds support for moq-transport Draft 15 protocol version alongside existing Draft 14, including version negotiation, ALPN handling, and version-aware encoding/decoding for messages.'
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title directly relates to the main change: adding initial support for moq-transport-15 (Draft 15) protocol version, which is the central theme of this changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/add-moq-transport-15-NTQJr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
CLAUDE.md (1)

86-86: ⚠️ Potential issue | 🟡 Minor

Fix typo: "formating" → "formatting".

📝 Proposed fix
-- Run `just fix` to auto-format and fix linting issues
+- Run `just fix` to auto-format and fix linting issues

Wait, let me provide the correct diff:

-2. Run `just fix` to auto-format and fix linting issues
+2. Run `just fix` to auto-format and fix linting issues

Actually, the typo is in "formating" not in the word "format". Let me fix this:

-- Run `just fix` to automatically fix formating and easy things.
+- Run `just fix` to automatically fix formatting and easy things.
🤖 Fix all issues with AI agents
In `@js/lite/src/connection/connect.ts`:
- Around line 141-142: Fix the typo in the TypeScript ignore comment above the
protocols line in connect.ts: change the comment string from
"WebTransportOptionsb" to "WebTransportOptions" (the comment that reads "//
`@ts-expect-error` - TODO: add protocols to WebTransportOptionsb") so the comment
correctly references WebTransportOptions for the protocols: [Ietf.ALPN.DRAFT_15]
entry.
- Around line 98-107: Update the typo in the comment inside the Ietf.ClientSetup
instantiation block: change "We still echo is just to make sure..." to "We still
echo it just to make sure..." near the code that checks setupVersion and
constructs the versions array (the Ietf.ClientSetup call and subsequent await
client.encode(stream.writer, setupVersion)); no logic changes needed—just
correct the comment wording to reference "it".

In `@js/lite/src/ietf/control.ts`:
- Around line 57-81: MessagesV15 currently omits TrackStatus so read() cannot
decode 0x0E messages; decide whether v15 should support TrackStatus and if so
add TrackStatus to the MessagesV15 map (mirroring MessagesV14 entry), ensuring
the mapping uses the same identifier (TrackStatus.id) and that
TrackStatusRequest/TrackStatus id values align with the v15 spec; update
MessagesV15 to include TrackStatus and run tests of the read() path to verify
0x0E/0x0D decoding is handled correctly.

In `@js/lite/src/ietf/publish.ts`:
- Around line 86-90: Add support for the "largest" (parameter ID 0x09) field in
MessageParameters and wire it through Publish v15 encode/decode: add a typed
accessor (getter/setter) largest / set_largest_object on MessageParameters that
reads/writes parameter ID 0x09, update the Publish v15 decode path to extract
params.largest (rather than hardcoding undefined) and pass it into the Publish
constructor, and update the Publish v15 encode path to set params.largest when
Publish.largest is present so the parameter is emitted. Ensure types/signatures
match existing MessageParameters accessors and keep behavior consistent with
other parameter fields.

In `@js/lite/src/ietf/subscribe.ts`:
- Around line 64-109: The DRAFT_14 decode path inconsistently allows groupOrder
=== 0 while DRAFT_15 rejects it; update the DRAFT_14 branch (in the decode logic
that reads subscriberPriority, groupOrder, forward, filterType and calls
Parameters.decode) to enforce the same validation as DRAFT_15 by throwing an
Error when groupOrder === 0 or > 2 (use the same error message: `unknown group
order: ${groupOrder}`), or if 0 is intentionally allowed add a clarifying
comment next to the DRAFT_14 groupOrder check explaining why behavior differs;
ensure the change references the Version.DRAFT_14 branch, the groupOrder
variable, and the Subscribe(...) return path.

In `@rs/moq-lite/src/client.rs`:
- Around line 100-103: The current fallback for MaxRequestId uses 0 which blocks
allocation; change the default when reading the parameter in the client (where
Parameters::decode is used and request_id_max is set) to use u32::MAX instead of
0 — i.e. when calling parameters.get_varint(ietf::ParameterVarInt::MaxRequestId)
use unwrap_or(u32::MAX) and ensure the produced ietf::RequestId value and any
casts match the existing type so the subsequent request_id_next < request_id_max
check behaves correctly.

In `@rs/moq-lite/src/ietf/fetch.rs`:
- Around line 224-227: The group_order conversion in the FetchOk decode uses a
truncating cast (v as u8); instead use a fallible conversion to u8 before
converting to GroupOrder to avoid truncation. Replace the `v as u8` usage in the
`let group_order = match params.group_order()` branch by first attempting
`u8::try_from(v)` and then passing that into `GroupOrder::try_from`, and if
either conversion fails fall back to `GroupOrder::Descending` (so keep the
existing unwrap_or/fallback behavior). Reference: params.group_order(),
GroupOrder::try_from.
- Around line 155-159: The code unsafely casts params.group_order()'s u64 to u8
with `v as u8`, which can silently truncate; change the branch that handles
`params.group_order()` so it attempts a fallible conversion (e.g.,
`u8::try_from(v)`) and on Err or when `GroupOrder::try_from` fails, fall back to
`GroupOrder::Descending` (same behavior you use for `GroupOrder::try_from`),
updating the `group_order` match that currently references
`params.group_order()` and `GroupOrder::try_from(v as u8)` to use the safe
conversion and fallback.

In `@rs/moq-lite/src/ietf/parameters.rs`:
- Around line 162-177: The JS constants file is missing two parameter IDs
present in Rust; add MSG_PARAM_AUTHORIZATION_TOKEN = 0x03n and
MSG_PARAM_LARGEST_OBJECT = 0x09n to js/lite/src/ietf/parameters.ts so JS matches
the Rust MessageParameters (AUTHORIZATION_TOKEN and LARGEST_OBJECT) and ensure
they're exported/placed alongside the existing MSG_PARAM_* constants used for
interop with functions that reference LARGEST_OBJECT in Rust.

In `@rs/moq-lite/src/ietf/publish.rs`:
- Around line 273-276: The encode path uses assert! which can panic at runtime
in encode_msg when an AbsoluteStart or AbsoluteRange filter reaches the Draft14
branch; replace the runtime assert with a debug_assert! (or, better,
validate/filter out Absolute* variants at construction time) so encode_msg no
longer can crash the process—locate the check referencing self.filter_type and
FilterType::LargestObject | FilterType::NextGroup in the Draft14 encoding branch
and either change assert! to debug_assert! or add earlier validation in the
constructor/factory that prevents AbsoluteStart/AbsoluteRange from being set for
Draft14.

In `@rs/moq-lite/src/ietf/subscribe.rs`:
- Around line 310-326: The Draft15 branch in Version::Draft15 of
SubscribeUpdate::decode currently hardcodes start_location and end_group instead
of reflecting their absence in v15; update the decode and struct so these fields
are Option types (start_location: Option<Location>, end_group: Option<u64>) and
set them to None in the Version::Draft15 decode path (where
MessageParameters::decode is used), and update other version branches to
populate Some(...) when those fields are present; ensure all uses of
SubscribeUpdate, plus encode/decode counterparts, are adjusted to the new Option
types.
- Around line 117-140: The encode path is hardcoding GroupOrder::Descending
instead of using the stored field, so update both branches to use
self.group_order: in the Version::Draft14 branch replace
GroupOrder::Descending.encode(w, version) with self.group_order.encode(w,
version), and in the Version::Draft15 branch call
params.set_group_order(u8::from(self.group_order) as u64) (instead of
u8::from(GroupOrder::Descending) as u64) so the encoded message reflects the
struct's group_order field.

In `@rs/moq-lite/src/setup.rs`:
- Around line 82-87: The expect() message "message be huge" is a typo and should
clearly state the invariant; update the two occurrences (the
u16::try_from(size).expect(...) calls in the match arm handling Version::Ietf
and the second occurrence at the other site) to a clear message such as "message
too large for u16" so the panic explains the exact failure when
u16::try_from(size) cannot convert.

In `@rs/moq-native/src/client.rs`:
- Line 430: Fix the typo in the TODO comment in rs/moq-native/src/client.rs:
change "Suupport multiple ALPNs" to "Support multiple ALPNs" (update the TODO
string near the ALPN handling comment in client.rs so the comment reads
correctly).
- Around line 312-317: The bail message in the match that computes alpns
(variable alpns, matching on url.scheme()) is inaccurate because it only
mentions 'http', 'https', or 'moqt' even though you also accept any scheme
present in moq_lite::alpns() (e.g., "moq-00", "moqt-15"); update the
anyhow::bail! call so the error text accurately lists or describes all accepted
schemes (for example: "url scheme must be 'http', 'https', 'moqt', or a
recognized moq ALPN") or dynamically include moq_lite::alpns() in the message to
avoid confusion. Ensure you reference the same match branch that currently
returns Err via anyhow::bail! so callers see the correct guidance.

In `@rs/moq-native/src/server.rs`:
- Around line 462-468: The ok() constructor creates a raw Session without
propagating the negotiated ALPN, so session.protocol() returns None and version
negotiation breaks; fix by extracting the negotiated ALPN from the established
connection's TLS state (via whatever API on self.connection exposes the
negotiated protocol) and supply that protocol when constructing the
ConnectRequest/ConnectResponse or when calling web_transport_quinn::Session::raw
so the Session carries the negotiated ALPN; update ok() to read the connection's
negotiated protocol and pass it through to Session::raw (affecting
session.protocol() used by accept in rs/moq-lite/src/server.rs).
🧹 Nitpick comments (15)
js/lite/src/ietf/publisher.ts (1)

233-242: TODO stubs for v15 request routing — track for follow-up.

Both handleRequestOk and handleRequestError are no-ops with TODO comments about routing by request_id. Since RequestOk/RequestError are generic messages that can correspond to subscribe, publish-namespace, or other requests, implementing the request-id-based dispatch is essential for correct v15 behavior.

Would you like me to open an issue to track implementing the request-id-based routing for RequestOk/RequestError in the publisher?

rs/moq-lite/src/ietf/request.rs (1)

145-156: Consider extending the round-trip test with non-default parameters.

The test uses MessageParameters::default() (empty), so the parameters encoding/decoding path isn't actually exercised beyond writing/reading a zero count. Adding a test case with at least one parameter would increase confidence.

rs/moq-lite/src/ietf/subscribe.rs (1)

94-97: Silent truncation: u64 as u8 for group_order could mask invalid values.

params.group_order() returns Option<u64>. The cast v as u8 silently truncates values > 255. While GroupOrder enum values are small, a malformed parameter could silently produce an incorrect GroupOrder variant or fall through to the unwrap_or(Descending) default.

Consider using u8::try_from(v) with a fallback:

Proposed fix
-				let group_order = match params.group_order() {
-					Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending),
-					None => GroupOrder::Descending,
-				};
+				let group_order = match params.group_order() {
+					Some(v) => u8::try_from(v)
+						.ok()
+						.and_then(|b| GroupOrder::try_from(b).ok())
+						.unwrap_or(GroupOrder::Descending),
+					None => GroupOrder::Descending,
+				};
js/lite/src/ietf/connection.ts (2)

209-216: Sequential routing of RequestOk/RequestError to both handlers risks skipping the second handler on error.

If this.#publisher.handleRequestOk(msg) throws, this.#subscriber.handleRequestOk(msg) is never called (and vice versa for RequestError). Since these generic v15 messages could belong to either side, consider using Promise.allSettled or try-catching individually:

Proposed fix
 			} else if (msg instanceof RequestOk) {
 				// v15: Route RequestOk to both publisher and subscriber
-				await this.#publisher.handleRequestOk(msg);
-				await this.#subscriber.handleRequestOk(msg);
+				await Promise.allSettled([
+					this.#publisher.handleRequestOk(msg),
+					this.#subscriber.handleRequestOk(msg),
+				]);
 			} else if (msg instanceof RequestError) {
 				// v15: Route RequestError to both publisher and subscriber
-				await this.#publisher.handleRequestError(msg);
-				await this.#subscriber.handleRequestError(msg);
+				await Promise.allSettled([
+					this.#publisher.handleRequestError(msg),
+					this.#subscriber.handleRequestError(msg),
+				]);
 			} else {

67-70: Constructor accepts optional version — default handled downstream.

The optional version parameter flows to Control.Stream which defaults to DRAFT_14. This is fine, but documenting the default in the JSDoc (line 63) would help callers understand the behavior without tracing through to Control.Stream.

rs/moq-lite/src/ietf/setup.rs (1)

80-84: Variable shadowing: version on line 82 shadows the IetfVersion parameter.

In the Draft14 path, let version = Version::decode(r, version)?; shadows the outer version: IetfVersion with a decoded Version (wire format). Then Parameters::decode(r, version) on line 83 uses this shadowed Version value instead of the IetfVersion enum. This likely works today since Parameters::decode is generic over V, but it's fragile — if Parameters ever needs the IetfVersion enum for version-specific behavior, this would silently break.

Consider renaming to avoid ambiguity:

Proposed fix
 		IetfVersion::Draft14 => {
-			let version = Version::decode(r, version)?;
-			let parameters = Parameters::decode(r, version)?;
-			Ok(Self { version, parameters })
+			let selected_version = Version::decode(r, version)?;
+			let parameters = Parameters::decode(r, version)?;
+			Ok(Self { version: selected_version, parameters })
 		}
rs/moq-lite/src/ietf/publish.rs (1)

388-468: Good test coverage for both Publish and PublishOk across v14/v15.

Consider adding a test for Publish with largest_location: None to cover the no-content-exists path for Draft14 and the missing parameter path for Draft15.

js/lite/src/ietf/parameters.ts (2)

126-250: MessageParameters duplicates Parameters encode/decode logic.

Both classes have identical encode/decode methods and the same vars/bytes map pattern. Consider extracting a shared base class or utility to reduce duplication. Not urgent given the small size, but worth noting for maintainability.


199-208: subscriptionFilter getter/setter assumes single-byte varint encoding.

The getter reads data[0] and the setter writes new Uint8Array([v]). This works for current filter type values (0–4) but would break silently for values ≥ 64 where a varint would require multi-byte encoding within the bytes payload. Since the spec-defined filter types are 0–3, this is fine for now.

rs/moq-lite/src/ietf/fetch.rs (1)

340-414: Tests only assert a subset of fields — consider full round-trip assertions.

The v14/v15 round-trip tests for both Fetch and FetchOk skip asserting group_order, fetch_type (for Fetch), and group_order (for FetchOk). A full assert_eq!(decoded, msg) would catch serialization regressions across all fields, since both structs derive PartialEq.

Example
-		assert_eq!(decoded.request_id, RequestId(1));
-		assert_eq!(decoded.subscriber_priority, 128);
+		assert_eq!(decoded, msg);
rs/moq-lite/src/ietf/subscriber.rs (1)

188-206: recv_request_error duplicates recv_subscribe_error logic — consider extracting a shared helper.

Lines 194–206 replicate the subscribe-state cleanup in recv_subscribe_error (lines 175–186). Extracting a small remove_subscribe(request_id) helper would reduce the duplication.

Example shared helper
+	fn remove_subscribe(&mut self, request_id: RequestId) {
+		let mut state = self.state.lock();
+		if let Some(track) = state.subscribes.remove(&request_id) {
+			track.producer.abort(Error::Cancel);
+			if let Some(alias) = track.alias {
+				state.aliases.remove(&alias);
+			}
+		}
+	}
+
 	pub fn recv_subscribe_error(&mut self, msg: ietf::SubscribeError) -> Result<(), Error> {
-		let mut state = self.state.lock();
-		if let Some(track) = state.subscribes.remove(&msg.request_id) {
-			track.producer.abort(Error::Cancel);
-			if let Some(alias) = track.alias {
-				state.aliases.remove(&alias);
-			}
-		}
+		self.remove_subscribe(msg.request_id);
 		Ok(())
 	}

 	pub fn recv_request_error(&mut self, msg: &ietf::RequestError<'_>) -> Result<(), Error> {
-		let mut state = self.state.lock();
-		if let Some(track) = state.subscribes.remove(&msg.request_id) {
-			track.producer.abort(Error::Cancel);
-			if let Some(alias) = track.alias {
-				state.aliases.remove(&alias);
-			}
-		}
+		self.remove_subscribe(msg.request_id);
 		Ok(())
 	}
rs/moq-lite/src/version.rs (1)

66-73: Nit: both Encode arms are identical — could simplify.

Both arms convert to coding::Version and encode. You could collapse this to a single expression since From<Version> for coding::Version is already implemented.

Simplified Encode
 impl<V> coding::Encode<V> for Version {
 	fn encode<W: bytes::BufMut>(&self, w: &mut W, v: V) {
-		match self {
-			Self::Ietf(version) => coding::Version::from(*version).encode(w, v),
-			Self::Lite(version) => coding::Version::from(*version).encode(w, v),
-		}
+		coding::Version::from(*self).encode(w, v)
 	}
 }
rs/moq-lite/src/ietf/parameters.rs (1)

146-160: Encoding emits all varints before all bytes — keys may not be globally sorted.

The encode iterates vars first then bytes, so a varint key 0x22 (GROUP_ORDER) would be emitted before a bytes key 0x03 (AUTHORIZATION_TOKEN), producing globally out-of-order keys. The protocol's count-based decode handles any order, so this isn't a correctness issue, but if strict key ordering is ever expected by a conformance test or peer, it would fail.

If you want globally sorted keys, you'd need to merge-iterate both maps. Low priority since the spec doesn't appear to mandate ordering.

js/lite/src/ietf/control.ts (1)

126-128: Type-unsafe cast to pass optional version to encode — pragmatic but fragile.

The as (w: Writer, v?: IetfVersion) => Promise<void> cast works at runtime because JS ignores extra arguments, but it completely bypasses TypeScript's type checking. If a message's encode signature changes in a breaking way, the compiler won't catch it.

Consider defining a common Encodable interface that all message classes implement, with an optional version parameter, to get compile-time safety.

rs/moq-lite/src/server.rs (1)

46-57: Add clarifying comment explaining why Draft15 is excluded from NEGOTIATED.

The codebase correctly implements ALPN-based version negotiation, but the design choice warrants documentation. NEGOTIATED (defined in version.rs) includes only [Lite(Draft02), Lite(Draft01), Ietf(Draft14)], excluding Ietf(Draft15). This is intentional: Draft15 uses a different encoding format (no versions list in setup messages) and therefore cannot participate in dynamic version negotiation—it requires explicit ALPN negotiation instead.

Consider adding a brief comment to the NEGOTIATED definition or the None case in server.rs line 55 explaining that Draft15 requires ALPN-based negotiation and cannot be selected from the fallback version list. This will help future readers understand the design rationale.

Comment on lines +57 to +81
// v15 message map — 0x05 → RequestError, 0x07 → RequestOk (different wire format)
// Messages removed in v15 (0x08, 0x0E, 0x12, 0x13, 0x19, 0x1E, 0x1F) are excluded and will be rejected
const MessagesV15 = {
[Setup.ClientSetup.id]: Setup.ClientSetup,
[Setup.ServerSetup.id]: Setup.ServerSetup,
[Subscribe.id]: Subscribe,
[SubscribeOk.id]: SubscribeOk,
[RequestError.id]: RequestError, // 0x05 → RequestError instead of SubscribeError
[PublishNamespace.id]: PublishNamespace,
[RequestOk.id]: RequestOk, // 0x07 → RequestOk instead of PublishNamespaceOk
[PublishNamespaceDone.id]: PublishNamespaceDone,
[Unsubscribe.id]: Unsubscribe,
[PublishDone.id]: PublishDone,
[PublishNamespaceCancel.id]: PublishNamespaceCancel,
[TrackStatusRequest.id]: TrackStatusRequest,
[GoAway.id]: GoAway,
[Fetch.id]: Fetch,
[FetchCancel.id]: FetchCancel,
[FetchOk.id]: FetchOk,
[SubscribeNamespace.id]: SubscribeNamespace,
[UnsubscribeNamespace.id]: UnsubscribeNamespace,
[Publish.id]: Publish,
[MaxRequestId.id]: MaxRequestId,
[RequestsBlocked.id]: RequestsBlocked,
} as const;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and examine the control.ts file
find . -name "control.ts" -path "*/lite/src/ietf/*" -type f

Repository: moq-dev/moq

Length of output: 84


🏁 Script executed:

# Read the control.ts file to verify the MessagesV15 map
cat -n js/lite/src/ietf/control.ts | head -100

Repository: moq-dev/moq

Length of output: 4540


🏁 Script executed:

# Search for TrackStatus definition and usage
rg "TrackStatus" js/lite/src/ietf/control.ts -B2 -A2

Repository: moq-dev/moq

Length of output: 619


🌐 Web query:

moq-transport draft-15 TRACK_STATUS message specification

💡 Result:

In draft-ietf-moq-transport-15, TRACK_STATUS is a control-stream request a potential subscriber sends to learn the current status of a track. (datatracker.ietf.org)

Wire format / message type

Semantics

  • The receiver processes TRACK_STATUS as if it were SUBSCRIBE, except it:
    • does not create downstream subscription state, and
    • does not send any Objects. (datatracker.ietf.org)

Responses

  • Success: publisher replies with REQUEST_OK, with the same parameters it would have set in SUBSCRIBE_OK; Track Alias is not used. (datatracker.ietf.org)
  • Failure: publisher replies with REQUEST_ERROR. (datatracker.ietf.org)

Important behavioral constraints

  • Publisher does not send PUBLISH_DONE for TRACK_STATUS.
  • Subscriber cannot send SUBSCRIBE_UPDATE or UNSUBSCRIBE for a TRACK_STATUS request. (datatracker.ietf.org)

If you want, paste the SUBSCRIBE message definition you’re using (or your implementation struct), and I’ll map it 1:1 to TRACK_STATUS for draft-15.

Citations:


🏁 Script executed:

# Check the track.ts file for message IDs
cat js/lite/src/ietf/track.ts

Repository: moq-dev/moq

Length of output: 2590


🏁 Script executed:

# Also check if there's specification documentation or comments about the IDs
rg "0x0D|0x0C" js/lite/src/ietf/ -B2 -A2

Repository: moq-dev/moq

Length of output: 37


🌐 Web query:

moq-transport draft-15 TRACK_STATUS message type 0x0D removed or kept

💡 Result:

In draft-ietf-moq-transport-15, TRACK_STATUS is kept as a control message, and its Message Type is 0x0D. It’s sent by a (potential) subscriber and uses the same on-wire format as SUBSCRIBE; the publisher replies with REQUEST_OK (success) or REQUEST_ERROR (failure). [1]

Note: in older drafts (e.g., draft-ietf-moq-transport-05), 0x0D was TRACK_STATUS_REQUEST and TRACK_STATUS was 0x0E, so the type code changed in later drafts. [2]

Sources:
[1] draft-ietf-moq-transport-15 (Control Message Types table; Section 9.19 TRACK_STATUS) (datatracker.ietf.org)
[2] draft-ietf-moq-transport-05 (message type assignments showing 0x0D TRACK_STATUS_REQUEST, 0x0E TRACK_STATUS) (datatracker.ietf.org)

Citations:


TrackStatus is missing from MessagesV15 — cannot decode 0x0E messages on v15 path.

MessagesV15 (line 59–81) excludes TrackStatus (id 0x0E), while MessagesV14 includes it (line 40). If a v15 peer sends a TrackStatus message, the read() method will throw "Unknown control message type" because the message map lookup will fail.

The code comment acknowledges 0x0E is intentionally removed in v15, but this warrants clarity: draft-ietf-moq-transport-15 does specify a TRACK_STATUS message (0x0D in the spec). The implementation uses draft-05-style IDs (TrackStatusRequest=0x0d, TrackStatus=0x0e), which may indicate either a version mismatch or an intentional deviation. Confirm whether v15 support should include TrackStatus decoding.

Proposed fix (if TrackStatus should be supported in v15)
 const MessagesV15 = {
 	...
 	[TrackStatusRequest.id]: TrackStatusRequest,
+	[TrackStatus.id]: TrackStatus,
 	[GoAway.id]: GoAway,
 	...
 } as const;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// v15 message map — 0x05 → RequestError, 0x07 → RequestOk (different wire format)
// Messages removed in v15 (0x08, 0x0E, 0x12, 0x13, 0x19, 0x1E, 0x1F) are excluded and will be rejected
const MessagesV15 = {
[Setup.ClientSetup.id]: Setup.ClientSetup,
[Setup.ServerSetup.id]: Setup.ServerSetup,
[Subscribe.id]: Subscribe,
[SubscribeOk.id]: SubscribeOk,
[RequestError.id]: RequestError, // 0x05 → RequestError instead of SubscribeError
[PublishNamespace.id]: PublishNamespace,
[RequestOk.id]: RequestOk, // 0x07 → RequestOk instead of PublishNamespaceOk
[PublishNamespaceDone.id]: PublishNamespaceDone,
[Unsubscribe.id]: Unsubscribe,
[PublishDone.id]: PublishDone,
[PublishNamespaceCancel.id]: PublishNamespaceCancel,
[TrackStatusRequest.id]: TrackStatusRequest,
[GoAway.id]: GoAway,
[Fetch.id]: Fetch,
[FetchCancel.id]: FetchCancel,
[FetchOk.id]: FetchOk,
[SubscribeNamespace.id]: SubscribeNamespace,
[UnsubscribeNamespace.id]: UnsubscribeNamespace,
[Publish.id]: Publish,
[MaxRequestId.id]: MaxRequestId,
[RequestsBlocked.id]: RequestsBlocked,
} as const;
// v15 message map — 0x05 → RequestError, 0x07 → RequestOk (different wire format)
// Messages removed in v15 (0x08, 0x0E, 0x12, 0x13, 0x19, 0x1E, 0x1F) are excluded and will be rejected
const MessagesV15 = {
[Setup.ClientSetup.id]: Setup.ClientSetup,
[Setup.ServerSetup.id]: Setup.ServerSetup,
[Subscribe.id]: Subscribe,
[SubscribeOk.id]: SubscribeOk,
[RequestError.id]: RequestError, // 0x05 → RequestError instead of SubscribeError
[PublishNamespace.id]: PublishNamespace,
[RequestOk.id]: RequestOk, // 0x07 → RequestOk instead of PublishNamespaceOk
[PublishNamespaceDone.id]: PublishNamespaceDone,
[Unsubscribe.id]: Unsubscribe,
[PublishDone.id]: PublishDone,
[PublishNamespaceCancel.id]: PublishNamespaceCancel,
[TrackStatusRequest.id]: TrackStatusRequest,
[TrackStatus.id]: TrackStatus,
[GoAway.id]: GoAway,
[Fetch.id]: Fetch,
[FetchCancel.id]: FetchCancel,
[FetchOk.id]: FetchOk,
[SubscribeNamespace.id]: SubscribeNamespace,
[UnsubscribeNamespace.id]: UnsubscribeNamespace,
[Publish.id]: Publish,
[MaxRequestId.id]: MaxRequestId,
[RequestsBlocked.id]: RequestsBlocked,
} as const;
🤖 Prompt for AI Agents
In `@js/lite/src/ietf/control.ts` around lines 57 - 81, MessagesV15 currently
omits TrackStatus so read() cannot decode 0x0E messages; decide whether v15
should support TrackStatus and if so add TrackStatus to the MessagesV15 map
(mirroring MessagesV14 entry), ensuring the mapping uses the same identifier
(TrackStatus.id) and that TrackStatusRequest/TrackStatus id values align with
the v15 spec; update MessagesV15 to include TrackStatus and run tests of the
read() path to verify 0x0E/0x0D decoding is handled correctly.

Comment on lines 64 to 109
if (version === Version.DRAFT_15) {
// v15: fields are in parameters
const params = await MessageParameters.decode(r);
const subscriberPriority = params.subscriberPriority ?? 128;
const groupOrder = params.groupOrder ?? GROUP_ORDER;
if (groupOrder > 2 || groupOrder === 0) {
throw new Error(`unknown group order: ${groupOrder}`);
}

const forward = params.forward ?? true;
if (!forward) {
throw new Error(`unsupported forward value: ${forward}`);
}

const filterType = params.subscriptionFilter ?? 0x2;
if (filterType !== 0x1 && filterType !== 0x2) {
throw new Error(`unsupported filter type: ${filterType}`);
}

return new Subscribe(requestId, trackNamespace, trackName, subscriberPriority);
} else if (version === Version.DRAFT_14) {
const subscriberPriority = await r.u8();

const groupOrder = await r.u8();
if (groupOrder > 2) {
throw new Error(`unknown group order: ${groupOrder}`);
}

const forward = await r.bool();
if (!forward) {
throw new Error(`unsupported forward value: ${forward}`);
}

const filterType = await r.u53();
if (filterType !== 0x1 && filterType !== 0x2) {
throw new Error(`unsupported filter type: ${filterType}`);
}

await Parameters.decode(r); // ignore parameters

return new Subscribe(requestId, trackNamespace, trackName, subscriberPriority);
} else {
const _: never = version;
throw new Error(`unsupported version: ${_}`);
}

const forward = await r.bool();
if (!forward) {
throw new Error(`unsupported forward value: ${forward}`);
}

const filterType = await r.u53();
if (filterType !== 0x1 && filterType !== 0x2) {
throw new Error(`unsupported filter type: ${filterType}`);
}

await Parameters.decode(r); // ignore parameters

return new Subscribe(requestId, trackNamespace, trackName, subscriberPriority);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent groupOrder === 0 validation between DRAFT_15 and DRAFT_14 decode paths.

DRAFT_15 (Line 69) rejects groupOrder === 0, but DRAFT_14 (Line 88) only checks groupOrder > 2, silently accepting 0. If groupOrder = 0 is invalid for both drafts, the DRAFT_14 path should also reject it. If it's valid in DRAFT_14 (e.g., "original publisher order"), then this is intentional and a comment explaining the difference would help.

📝 If groupOrder 0 should be rejected in DRAFT_14 too
 		} else if (version === Version.DRAFT_14) {
 			const subscriberPriority = await r.u8();
 
 			const groupOrder = await r.u8();
-			if (groupOrder > 2) {
+			if (groupOrder > 2 || groupOrder === 0) {
 				throw new Error(`unknown group order: ${groupOrder}`);
 			}
🤖 Prompt for AI Agents
In `@js/lite/src/ietf/subscribe.ts` around lines 64 - 109, The DRAFT_14 decode
path inconsistently allows groupOrder === 0 while DRAFT_15 rejects it; update
the DRAFT_14 branch (in the decode logic that reads subscriberPriority,
groupOrder, forward, filterType and calls Parameters.decode) to enforce the same
validation as DRAFT_15 by throwing an Error when groupOrder === 0 or > 2 (use
the same error message: `unknown group order: ${groupOrder}`), or if 0 is
intentionally allowed add a clarifying comment next to the DRAFT_14 groupOrder
check explaining why behavior differs; ensure the change references the
Version.DRAFT_14 branch, the groupOrder variable, and the Subscribe(...) return
path.

Comment on lines +310 to +326
Version::Draft15 => {
let request_id = RequestId::decode(r, version)?;
let subscription_request_id = RequestId::decode(r, version)?;
let params = MessageParameters::decode(r, version)?;

let subscriber_priority = params.subscriber_priority().unwrap_or(128);
let forward = params.forward().unwrap_or(true);

Ok(Self {
request_id,
subscription_request_id,
start_location: Location { group: 0, object: 0 },
end_group: 0,
subscriber_priority,
forward,
})
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

v15 SubscribeUpdate decode drops start_location and end_group.

In the Draft15 decode path, start_location is set to Location { group: 0, object: 0 } and end_group to 0, regardless of what the sender intended. These fields exist on the SubscribeUpdate struct but aren't encoded in v15 (lines 278-286 only encode params). If Draft 15 truly removed these fields, consider whether the struct should use Option types to make the absence explicit rather than silently defaulting.

🤖 Prompt for AI Agents
In `@rs/moq-lite/src/ietf/subscribe.rs` around lines 310 - 326, The Draft15 branch
in Version::Draft15 of SubscribeUpdate::decode currently hardcodes
start_location and end_group instead of reflecting their absence in v15; update
the decode and struct so these fields are Option types (start_location:
Option<Location>, end_group: Option<u64>) and set them to None in the
Version::Draft15 decode path (where MessageParameters::decode is used), and
update other version branches to populate Some(...) when those fields are
present; ensure all uses of SubscribeUpdate, plus encode/decode counterparts,
are adjusted to the new Option types.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@js/lite/src/connection/connect.ts`:
- Around line 76-87: The code currently treats session.protocol === undefined as
"no ALPN", but per the WebTransport spec session.protocol can be an empty string
when no protocol is negotiated; update the logic around the protocol variable
(session.protocol) and the setupVersion selection so that an empty string ('')
is treated the same as undefined: if protocol equals Ietf.ALPN.DRAFT_15 set
setupVersion = Ietf.Version.DRAFT_15, otherwise if protocol is falsy or '' set
setupVersion = Ietf.Version.DRAFT_14, else throw the same unsupported
WebTransport protocol error; adjust the condition checks near the protocol
declaration and the setupVersion assignment to reference session.protocol /
protocol accordingly.

In `@js/lite/src/ietf/parameters.ts`:
- Around line 218-227: The subscriptionFilter getter and setter in the
subscriptionFilter accessor read/write a raw byte (using
this.bytes.get(MSG_PARAM_SUBSCRIPTION_FILTER) and new Uint8Array([v])) which
bypasses proper varint encoding/decoding and will break for values >= 64; update
the accessor to decode and encode the value using the same varint routines used
elsewhere (use the module's varint read/encode helpers or implement varint read
for the getter and varint encode for the setter) so
MSG_PARAM_SUBSCRIPTION_FILTER stores a properly encoded varint and the getter
decodes it back to a number, and also update the comment to explicitly note the
previous single-byte limitation if you keep a fast-path for single-byte values.
- Around line 209-216: The largest setter currently converts bigint to Number
causing silent truncation; update the setter (largest) to encode bigints without
casting by using the varint module's bigint-capable API (e.g., Varint.encodeTo
or the encode variant that accepts bigint) for both v.groupId and v.objectId,
then concat into the combined Uint8Array and set
this.bytes.set(MSG_PARAM_LARGEST_OBJECT, combined); alternatively, if encodeTo
requires a preallocated buffer, allocate appropriately and use the returned
length—do not use Number(...) and do not silently allow values >
Number.MAX_SAFE_INTEGER.
🧹 Nitpick comments (8)
rs/moq-lite/src/setup.rs (2)

25-28: assert_eq! in encode path panics instead of returning an error.

Both Client::encode_inner (line 27) and Server::encode_inner (line 107) use assert_eq! to validate Draft15 invariants. Since Encode doesn't return Result, this is constrained by the trait, but callers constructing a Client with mismatched versions will get an opaque panic. A debug_assert_eq! would be less surprising in release builds, or consider documenting this precondition on the struct.


46-48: u64 as usize can silently truncate on 32-bit targets.

Lines 47, 86, 129, and 145 cast decoded u64/u16 values to usize. On 32-bit platforms, a u64 value exceeding u32::MAX would silently truncate, leading to an undersized buffer read and subtle corruption. Consider using usize::try_from(...).map_err(|_| DecodeError::InvalidValue)? for the u64 paths.

Proposed fix (example for line 47)
-			Version::Lite(lite::Version::Draft02 | lite::Version::Draft01) => u64::decode(r, v)? as usize,
+			Version::Lite(lite::Version::Draft02 | lite::Version::Draft01) => {
+				usize::try_from(u64::decode(r, v)?).map_err(|_| DecodeError::InvalidValue)?
+			}

Apply the same pattern at line 145.

js/lite/src/ietf/parameters.ts (1)

128-269: MessageParameters largely duplicates Parameters — consider a shared base or utility.

Both classes have identical vars/bytes maps, identical encode/decode logic, and the same even/odd ID convention. The only difference is the typed accessors on MessageParameters. Extracting the core map + encode/decode into a shared base would reduce duplication.

This is non-blocking for this PR but worth tracking.

rs/moq-lite/src/ietf/fetch.rs (1)

348-422: Round-trip tests don't assert group_order or fetch_type survive the encode/decode cycle.

The v14 and v15 round-trip tests for both Fetch and FetchOk only check request_id, subscriber_priority, end_of_track, and end_location. Consider adding assertions for group_order and fetch_type to confirm these fields are correctly preserved, especially since v15 routes them through MessageParameters.

Example additions for test_fetch_v15_round_trip
 		assert_eq!(decoded.request_id, RequestId(1));
 		assert_eq!(decoded.subscriber_priority, 128);
+		assert_eq!(decoded.group_order, GroupOrder::Descending);
+		assert_eq!(decoded.fetch_type, msg.fetch_type);
rs/moq-native/src/client.rs (1)

331-352: Shadowed alpns on Line 334 may confuse readers.

Inside the if request.url.scheme() == "https" branch, a new alpns is declared that shadows the one from Line 312. The outer alpns (for "https") is ["h3"] (TLS ALPN), while the inner one (Line 334) is all MoQ ALPNs (WebTransport sub-protocol negotiation). The semantics are different but the names are identical.

Consider renaming the inner variable to protocols or sub_protocols to clarify intent:

Suggested rename
 		let session = if request.url.scheme() == "https" {
-			let alpns: Vec<String> = moq_lite::alpns().iter().map(|alpn| alpn.to_string()).collect();
-			let request = request.with_protocols(alpns);
+			let protocols: Vec<String> = moq_lite::alpns().iter().map(|alpn| alpn.to_string()).collect();
+			let request = request.with_protocols(protocols);
 			web_transport_quinn::Session::connect(connection, request).await?
rs/moq-lite/src/ietf/subscribe.rs (1)

56-113: Subscribe v14 decode discards start location from AbsoluteStart and AbsoluteRange filter types.

Lines 69-73 decode optional location fields for AbsoluteStart/AbsoluteRange but discard them with let _start / let _end_group. The struct only stores filter_type without the associated location data. If the intent is to reject absolute filters, consider returning Err(DecodeError::Unsupported) for those cases (similar to how forward == false is rejected on Line 63). If they're intentionally tolerated but not used, this is fine for now but a doc comment would help.

rs/moq-lite/src/ietf/publish.rs (2)

230-237: Repeated group_order decoding pattern — consider extracting a helper.

This exact block appears 6 times across fetch.rs, subscribe.rs, and publish.rs:

let group_order = match params.group_order() {
    Some(v) => u8::try_from(v)
        .ok()
        .and_then(|v| GroupOrder::try_from(v).ok())
        .map(GroupOrder::any_to_descending)
        .unwrap_or(GroupOrder::Descending),
    None => GroupOrder::Descending,
};

A method on MessageParameters (e.g., params.group_order_or_default()) or a free function on GroupOrder would eliminate this duplication.

Possible helper on GroupOrder
impl GroupOrder {
    /// Decode group_order from MessageParameters, defaulting to Descending.
    pub fn from_params(params: &MessageParameters) -> Self {
        match params.group_order() {
            Some(v) => u8::try_from(v)
                .ok()
                .and_then(|v| GroupOrder::try_from(v).ok())
                .map(GroupOrder::any_to_descending)
                .unwrap_or(GroupOrder::Descending),
            None => GroupOrder::Descending,
        }
    }
}

396-476: Good test coverage for both Publish and PublishOk across v14 and v15.

Tests verify key fields round-trip correctly. Similar to fetch.rs, you might also want to assert group_order and filter_type survive the encode/decode cycle.

Comment on lines +76 to +87
// @ts-expect-error - TODO: add protocol to WebTransport
const protocol = session instanceof WebTransport ? session.protocol : undefined;

// Choose setup encoding based on negotiated WebTransport protocol (if any).
let setupVersion: Ietf.Version;
if (protocol === Ietf.ALPN.DRAFT_15) {
setupVersion = Ietf.Version.DRAFT_15;
} else if (protocol === undefined) {
setupVersion = Ietf.Version.DRAFT_14;
} else {
throw new Error(`unsupported WebTransport protocol: ${protocol}`);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

WebTransport.protocol return value empty string vs undefined ALPN negotiation

💡 Result:

WebTransport.protocol is specified as a readonly DOMString, so in spec-compliant implementations it should not be undefined; it should be a string (possibly ""). (w3.org)

When you should see "" (empty string)

Per the WebTransport spec, protocol returns the server-selected application protocol only if:

  1. the session is established and
  2. you passed a non-empty protocols array in the constructor options;
    otherwise it returns the empty string. (w3.org)

Concretely, "" can mean:

  • you didn’t provide protocols, or provided []; or
  • you provided protocols, but the server didn’t select one (i.e., no effective WT-Protocol choice was made / honored).

How this relates to “ALPN”

WebTransport’s “ALPN-like” negotiation is not TLS ALPN; it’s done at the WebTransport/HTTP3 layer using:

  • client: WT-Available-Protocols
  • server: WT-Protocol (must be one of the client’s offered values, otherwise it’s ignored) (datatracker.ietf.org)

So WebTransport.protocol corresponds to the result of the WT-Protocol selection (if any), not the TLS ALPN value. (datatracker.ietf.org)

If you’re seeing undefined

That’s not what the spec describes for protocol; it usually indicates an environment/implementation mismatch (older/experimental browser build, polyfill, or a non-browser runtime API surface), rather than a meaningful “no protocol negotiated” signal. (w3.org)

Citations:


session.protocol returns an empty string (not undefined) when no ALPN protocol is negotiated per the WebTransport spec.

Per the W3C WebTransport specification, protocol is a readonly DOMString that returns "" (empty string) when no protocol is negotiated, not undefined. The current code on line 83 checks protocol === undefined, which would not catch the empty string case. If no protocol is selected, the condition would fail and execution would fall through to the else branch on line 85, throwing an "unsupported WebTransport protocol" error.

🐛 Proposed fix: handle empty string the same as undefined
 	if (protocol === Ietf.ALPN.DRAFT_15) {
 		setupVersion = Ietf.Version.DRAFT_15;
-	} else if (protocol === undefined) {
+	} else if (!protocol) {
 		setupVersion = Ietf.Version.DRAFT_14;
 	} else {
 		throw new Error(`unsupported WebTransport protocol: ${protocol}`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// @ts-expect-error - TODO: add protocol to WebTransport
const protocol = session instanceof WebTransport ? session.protocol : undefined;
// Choose setup encoding based on negotiated WebTransport protocol (if any).
let setupVersion: Ietf.Version;
if (protocol === Ietf.ALPN.DRAFT_15) {
setupVersion = Ietf.Version.DRAFT_15;
} else if (protocol === undefined) {
setupVersion = Ietf.Version.DRAFT_14;
} else {
throw new Error(`unsupported WebTransport protocol: ${protocol}`);
}
// `@ts-expect-error` - TODO: add protocol to WebTransport
const protocol = session instanceof WebTransport ? session.protocol : undefined;
// Choose setup encoding based on negotiated WebTransport protocol (if any).
let setupVersion: Ietf.Version;
if (protocol === Ietf.ALPN.DRAFT_15) {
setupVersion = Ietf.Version.DRAFT_15;
} else if (!protocol) {
setupVersion = Ietf.Version.DRAFT_14;
} else {
throw new Error(`unsupported WebTransport protocol: ${protocol}`);
}
🤖 Prompt for AI Agents
In `@js/lite/src/connection/connect.ts` around lines 76 - 87, The code currently
treats session.protocol === undefined as "no ALPN", but per the WebTransport
spec session.protocol can be an empty string when no protocol is negotiated;
update the logic around the protocol variable (session.protocol) and the
setupVersion selection so that an empty string ('') is treated the same as
undefined: if protocol equals Ietf.ALPN.DRAFT_15 set setupVersion =
Ietf.Version.DRAFT_15, otherwise if protocol is falsy or '' set setupVersion =
Ietf.Version.DRAFT_14, else throw the same unsupported WebTransport protocol
error; adjust the condition checks near the protocol declaration and the
setupVersion assignment to reference session.protocol / protocol accordingly.

Comment on lines +209 to +216
set largest(v: { groupId: bigint; objectId: bigint }) {
const buf1 = Varint.encode(Number(v.groupId));
const buf2 = Varint.encode(Number(v.objectId));
const combined = new Uint8Array(buf1.length + buf2.length);
combined.set(buf1, 0);
combined.set(buf2, buf1.length);
this.bytes.set(MSG_PARAM_LARGEST_OBJECT, combined);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine the Varint module
fd -t f "varint" | head -20

Repository: moq-dev/moq

Length of output: 136


🏁 Script executed:

# View the relevant section of the parameters.ts file to see getter and setter
sed -n '201,227p' js/lite/src/ietf/parameters.ts

Repository: moq-dev/moq

Length of output: 1094


🏁 Script executed:

# Search for Varint.encode calls to understand usage patterns
rg "Varint\.encode" -A 1 -B 1

Repository: moq-dev/moq

Length of output: 2837


🏁 Script executed:

# Check for tests related to MessageParameters or largest
fd -t f "test\|spec" --type f | xargs rg "largest|MessageParameters" -l 2>/dev/null | head -10

Repository: moq-dev/moq

Length of output: 380


🏁 Script executed:

# Examine the Varint module to see encode signature
cat js/lite/src/varint.ts

Repository: moq-dev/moq

Length of output: 3158


🏁 Script executed:

# Check for tests or usages of MessageParameters.largest
rg "\.largest" js/lite/src/ietf/parameters.ts

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Search for MessageParameters instantiation or usage patterns
rg "MessageParameters" -A 3 -B 1 | head -50

Repository: moq-dev/moq

Length of output: 2677


🏁 Script executed:

# Check if there are tests for MessageParameters.largest
rg "largest" js/lite/src/ -B 2 -A 2

Repository: moq-dev/moq

Length of output: 4620


🏁 Script executed:

# Look at the varint test file to understand the handling of large values
cat js/lite/src/varint.test.ts

Repository: moq-dev/moq

Length of output: 4793


largest setter truncates bigint values to number, causing silent precision loss.

Varint.encode(Number(v.groupId)) and Varint.encode(Number(v.objectId)) convert bigint to JavaScript's 53-bit safe integer range. For groupId/objectId values exceeding Number.MAX_SAFE_INTEGER (2^53 - 1), data is silently truncated. The protocol specification allows values up to 2^62, but this setter only safely handles up to 2^53. The getter correctly returns bigint, making this asymmetric and fragile.

The encodeTo() function in the varint module accepts bigint and handles up to 2^62. Consider using it instead, or validate that values do not exceed Number.MAX_SAFE_INTEGER.

🤖 Prompt for AI Agents
In `@js/lite/src/ietf/parameters.ts` around lines 209 - 216, The largest setter
currently converts bigint to Number causing silent truncation; update the setter
(largest) to encode bigints without casting by using the varint module's
bigint-capable API (e.g., Varint.encodeTo or the encode variant that accepts
bigint) for both v.groupId and v.objectId, then concat into the combined
Uint8Array and set this.bytes.set(MSG_PARAM_LARGEST_OBJECT, combined);
alternatively, if encodeTo requires a preallocated buffer, allocate
appropriately and use the returned length—do not use Number(...) and do not
silently allow values > Number.MAX_SAFE_INTEGER.

Comment on lines +218 to +227
get subscriptionFilter(): number | undefined {
const data = this.bytes.get(MSG_PARAM_SUBSCRIPTION_FILTER);
if (!data || data.length === 0) return undefined;
// Filter type is a varint — for our purposes, the first byte suffices
return data[0];
}

set subscriptionFilter(v: number) {
this.bytes.set(MSG_PARAM_SUBSCRIPTION_FILTER, new Uint8Array([v]));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

subscriptionFilter getter/setter bypasses varint encoding.

The getter reads data[0] directly and the setter writes a raw byte new Uint8Array([v]). Since filter type is a varint per the spec, this only works correctly for values 0–63 (single-byte varint range). Values ≥ 64 would encode differently as a varint vs. a raw byte.

This is likely fine for now since current filter types are small, but the comment on Line 221 should acknowledge the limitation more precisely.

🤖 Prompt for AI Agents
In `@js/lite/src/ietf/parameters.ts` around lines 218 - 227, The
subscriptionFilter getter and setter in the subscriptionFilter accessor
read/write a raw byte (using this.bytes.get(MSG_PARAM_SUBSCRIPTION_FILTER) and
new Uint8Array([v])) which bypasses proper varint encoding/decoding and will
break for values >= 64; update the accessor to decode and encode the value using
the same varint routines used elsewhere (use the module's varint read/encode
helpers or implement varint read for the getter and varint encode for the
setter) so MSG_PARAM_SUBSCRIPTION_FILTER stores a properly encoded varint and
the getter decodes it back to a number, and also update the comment to
explicitly note the previous single-byte limitation if you keep a fast-path for
single-byte values.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rs/moq-native/src/quiche.rs (1)

261-265: ⚠️ Potential issue | 🟡 Minor

Server accept constructs a URL without the peer's hostname.

Line 263 uses "moqt://" (no host) whereas the equivalent code in quinn.rs (line 320) uses format!("moqt://{}", host) to include the peer's hostname. If anything downstream inspects request.url() (e.g., for logging, routing, or access control), the missing host would be a problem.

Suggested fix
 alpn if moq_lite::alpns().contains(&alpn) => Ok(Self::Raw {
     connection: conn,
-    request: ConnectRequest::new("moqt://".to_string().parse::<Url>().unwrap()),
+    request: ConnectRequest::new(format!("moqt://{}", conn.peer_addr()).parse::<Url>().unwrap()),
     response: web_transport_quiche::proto::ConnectResponse::OK.with_protocol(alpn),
 }),

Note: The exact source of the hostname may differ from conn.peer_addr() — adjust based on what quiche exposes for the SNI or peer identity. Verify whether peer_addr() or another field is appropriate here.

🤖 Fix all issues with AI agents
In `@rs/moq-lite/src/server.rs`:
- Around line 104-106: The fallback default for MaxRequestId is wrong: when
decoding ietf::Parameters from client.parameters in server.rs you set
request_id_max to RequestId(0) which blocks server-initiated requests; change
the unwrap_or default used with
parameters.get_varint(ietf::ParameterVarInt::MaxRequestId) to u32::MAX as u64 so
request_id_max becomes ietf::RequestId(u32::MAX) (same fix as in client.rs),
preserving the surrounding call to ietf::Parameters::decode and the RequestId
wrapper.

In `@rs/moq-native/src/quiche.rs`:
- Around line 75-86: The quiche raw-connection branch is not propagating the
negotiated ALPN into the ConnectResponse like quinn does; after obtaining conn
from builder.connect(&host, port).await, extract the negotiated protocol from
conn (e.g., use the connection's ALPN/negotiated_protocol accessor) and pass it
into the response via
web_transport_quiche::proto::ConnectResponse::OK.with_protocol(alpn) when
constructing web_transport_quiche::Connection::raw so the negotiated protocol is
present in the returned response.

In `@rs/moq-native/src/quinn.rs`:
- Around line 82-86: The bail error message in the match on url.scheme() (where
alpns is computed) is incorrect: it omits the "moqt" scheme and mentions "http"
which isn't handled; update the error string in that match arm to accurately
list the accepted schemes (e.g., 'https', 'moqt', and 'moql') so the message
reflects the actual branches in the url.scheme() match.
🧹 Nitpick comments (3)
rs/moq-lite/src/client.rs (1)

81-85: Error diagnostic is slightly misleading — both sides of the error are the same list.

client.versions was set from supported.clone().into() on line 70, so the error at line 85 passes what are effectively the same versions for both the "offered" and "supported" arguments. Consider using vec![server.version] as one side to clarify that it was the server's chosen version that didn't match.

rs/moq-native/src/iroh.rs (1)

69-74: Hardcoded ALPN list diverges from the dynamic pattern used by quinn/quiche backends.

The quinn and quiche server implementations iterate over moq_lite::alpns() to populate ALPNs dynamically, whereas here the IETF ALPNs are hardcoded. If a Draft 16 ALPN is added to moq_lite::alpns(), this file will silently miss it.

Consider aligning with the other backends:

♻️ Suggested refactor
 let mut builder = IrohEndpoint::builder().secret_key(secret_key).alpns(vec![
     web_transport_iroh::ALPN_H3.as_bytes().to_vec(),
     moq_lite::lite::ALPN.as_bytes().to_vec(),
-    moq_lite::ietf::ALPN_14.as_bytes().to_vec(),
-    moq_lite::ietf::ALPN_15.as_bytes().to_vec(),
 ]);
+// Dynamically add all moq_lite ALPNs
+let mut alpn_list = vec![
+    web_transport_iroh::ALPN_H3.as_bytes().to_vec(),
+    moq_lite::lite::ALPN.as_bytes().to_vec(),
+];
+for alpn in moq_lite::alpns() {
+    alpn_list.push(alpn.as_bytes().to_vec());
+}
+let mut builder = IrohEndpoint::builder().secret_key(secret_key).alpns(alpn_list);
rs/moq-native/src/quinn.rs (1)

212-220: Potential duplicate ALPNs in server TLS config.

moq_lite::lite::ALPN is added explicitly on line 214, then moq_lite::alpns() is iterated on lines 216–218. If alpns() already includes the lite ALPN, it'll appear twice. Duplicate ALPN entries are generally harmless but could be confusing. This depends on the same question flagged in the iroh review — whether alpns() includes the lite ALPN.

Comment on lines +104 to +106
let parameters = ietf::Parameters::decode(&mut client.parameters, version)?;
let request_id_max =
ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Default of 0 for MaxRequestId blocks all server-initiated requests — should be u32::MAX.

This is the same bug that was already fixed in client.rs (line 105 uses unwrap_or(u32::MAX as u64)). When the client omits MaxRequestId, the server falls back to RequestId(0), which prevents any request ID allocation since the check request_id_next < request_id_max fails immediately when both are 0.

🐛 Proposed fix
 let request_id_max =
-	ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0));
+	ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(u32::MAX as u64));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let parameters = ietf::Parameters::decode(&mut client.parameters, version)?;
let request_id_max =
ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0));
let parameters = ietf::Parameters::decode(&mut client.parameters, version)?;
let request_id_max =
ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(u32::MAX as u64));
🤖 Prompt for AI Agents
In `@rs/moq-lite/src/server.rs` around lines 104 - 106, The fallback default for
MaxRequestId is wrong: when decoding ietf::Parameters from client.parameters in
server.rs you set request_id_max to RequestId(0) which blocks server-initiated
requests; change the unwrap_or default used with
parameters.get_varint(ietf::ParameterVarInt::MaxRequestId) to u32::MAX as u64 so
request_id_max becomes ietf::RequestId(u32::MAX) (same fix as in client.rs),
preserving the surrounding call to ietf::Parameters::decode and the RequestId
wrapper.

@kixelated kixelated enabled auto-merge (squash) February 11, 2026 21:43
@kixelated kixelated disabled auto-merge February 11, 2026 21:43
@kixelated kixelated merged commit 724c864 into main Feb 11, 2026
1 check passed
@kixelated kixelated deleted the claude/add-moq-transport-15-NTQJr branch February 11, 2026 21:43
This was referenced Feb 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants